自製離線優先的 kobo 漫畫閱讀 web app [PWA]
2024-12-02
前言
Kobo 提供了很多繁體中文翻譯的漫畫,在電子閱讀器的閱讀體驗已經很完美了,但假如想在手機上隨便翻個幾話漫畫的話,官方的 Kobo Book App 使用體驗就不是那麼理想了,那個奇怪的分類排序、沒有滾頁模式、跳到指定頁數的奇怪 UX 等等,讓我有了建立一個漫畫 APP 的念頭
原碼
https://github.com/auphone/comic-reader
下載 Kobo 漫畫
雖然下載回來的漫畫都是在 Kobo 正途購買的,但我們要移除 DRM 才能閱讀 epub, 所以請自行承擔風險吧
先用 kobo-book-downloader 連接自己的 Kobo 帳戶並且下載所有書本同時移除 DRM,如果像我一樣無法用它的 CLI 取得 token,可以嘗試用這個版本取得 token 並自行修改以下檔案
~/.config/kobodl.json
這是我的下載指令
./venv/bin/poetry run kobodl book get \
--output-dir /Storage/kobodl \
--get-all
下載後 /Storage/kobodl
目錄如下
.
├── 冨樫義博 - 獵人 (1) 1274eff9.epub
├── 冨樫義博 - 獵人 (10) 6ca55da6.epub
├── 冨樫義博 - 獵人 (11) e9fbea1a.epub
├── 冨樫義博 - 獵人 (12) dbadb136.epub
├── 冨樫義博 - 獵人 (13) edf987e5.epub
├── 冨樫義博 - 獵人 (14) b90db96d.epub
├── 冨樫義博 - 獵人 (15) 7e6bedff.epub
├── 冨樫義博 - 獵人 (16) df851abb.epub
├── 冨樫義博 - 獵人 (17) ee22e999.epub
├── 冨樫義博 - 獵人 (18) 00622219.epub
├── 冨樫義博 - 獵人 (19) 511db3db.epub
└── 冨樫義博 - 獵人 (2) 24a747de.epub
抽取 .epub 圖片
因為漫畫不需要 epub 的功能,例如查看參考連結、目錄連結等等,所以把圖片抽取出來比較方便使用,我用 bun 編寫了一個簡單的腳本
mkdir epub-to-image
cd epub-to-image
bun init -y
bun add epub fs-extra @types/fs-extra
修改 index.ts
,取得圖片
import * as fs from "fs-extra";
import * as path from "path";
import Epub from "epub";
const EPUB_PATH = "/Storage/kobodl/"; // 指向剛剛下載的 epub 目錄
const MANGA_PATH = "/Storage/manga/"; // 指向儲存圖片的地方
async function extractImages(
epubPath: string,
outputDir: string
): Promise<void> {
return new Promise((resolve, reject) => {
console.time("extract");
const epub = new Epub(epubPath);
const tasks: { id: string; imagePath: string }[] = [];
epub.on("error", (err) => {
console.error("Error reading EPUB:", err);
reject(err);
});
epub.on("end", async () => {
const spineItems = epub.spine.contents;
await fs.ensureDir(outputDir);
for (let obj of Object.values(epub.manifest)) {
if (obj["media-type"].toString().startsWith("image")) {
const filename = path.basename(obj.href);
const imagePath = path.join(outputDir, filename);
try {
await fs.stat(imagePath);
} catch (err) {
tasks.push({ id: obj.id, imagePath });
}
}
}
if (tasks.length === 0) {
resolve(undefined);
} else {
for (let task of tasks) {
await new Promise((rs, rj) => {
epub.getImage(task.id, async (err, data) => {
await fs.writeFile(task.imagePath, data);
rs(undefined);
});
});
}
console.timeEnd("extract");
resolve(undefined);
}
});
epub.parse();
});
}
(async () => {
const comics = await fs.readdir(EPUB_PATH);
for (let file of comics) {
const epubPath = path.join(EPUB_PATH, file);
const output = path.join(MANGA_PATH, file);
console.log(file);
try {
await extractImages(epubPath, output);
} catch (err) {
console.error(err);
}
}
})();
執行腳本
bun .
儲存圖片的目錄 /Storage/manga/
.
├── 冨樫義博 - 獵人 (1) 1274eff9.epub/
│ ├── cover.jpg
│ ├── i-021.jpg
│ ├── i-042.jpg
│ ├── i-063.jpg
│ ├── i-084.jpg
│ ├── i-105.jpg
│ ├── i-126.jpg
│ ├── i-147.jpg
│ ├── i-168.jpg
│ ├── i-001.jpg
│ └── ...
├── 冨樫義博 - 獵人 (10) 6ca55da6.epub/
│ ├── cover.jpg
│ └── ...
└── ...
製作離線優先的 PWA
取得圖片以後便可以動手製作閱讀器,這次選擇用 progressive web app (PWA),PWA 的緩存功能給我們提供了大量的儲存空間,可以讓我們離線使用,開發上比 native app 敏捷,同時保留了使用 APP 的體驗,這邊紀錄一下開發的要點
資料儲存方法
主要的資料例如漫畫圖片會放到 service worker,其他例如閱讀紀錄這些 state
我會儲存到 localStorage
,兩者同樣可以離線使用
Service worker (SW)
這次我用 sveltekit 編寫,由於這是一個比較簡單的項目,所以只用了普通的 service worker 處理,而沒有使用 Vite PWA 或是 workbox 這類工具,但相對容易處理得多,想了解多一點 SW 的話推薦觀看這段影片
建立緩存
這次分別建立了 APP_CACHE
、COMIC_CACHE
和 COMIC_IMAGE_CACHE
,一般需要更新的緩存放到 APP_CACHE
,其他不常更新的漫畫料訊會放到 COMIC_CACHE
,最後把不會更新漫畫圖片的放到 COMIC_IMAGE_CACHE
import { build, files, version } from "$service-worker";
const APP_CACHE = `cache-${version}`;
const COMIC_CACHE = `comics`;
達到離線優先
因為這不是一個常常更新的 APP,所以我們可以慣性地先從緩存取得資料 cachedResponse
並立即回傳,然後才考慮更新的問題
const cachedResponse =
(await cache.match(event.request)) || (await comicCache.match(event.request));
const fetchPromise = fetch(...).then(...); // 不是 async task
return (
cachedResponse ||
fetchPromise.then((response) => {
if (!response) {
throw new Error("No response received");
}
return response;
})
);
我們可以運用 JS 的 async 功能,使 fetchPromise
在不管有沒有 cachedResponse
的情況下也會執行,確保下次刷新的時候能取得新的資料,這樣即使在不良的網絡環境下 (例如在升降機內) 也不會卡在載入畫面,壞處就是有更新的時候可能需要多刷新一次
Manifest.json
我們可以用一些生成器如https://manifest-gen.netlify.app/產生 manifest.json
和圖片,讓瀏覽器能夠認知這是一個 PWA
{
"name": "My Manga",
"short_name": "My Manga",
"theme_color": "#000",
"background_color": "#000",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icon-48-48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "icon-192-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
在 <head />
插入 manifest
<!DOCTYPE html>
<html>
<head>
...
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<title>My Manga</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
...
</html>
確認 SW 的運行狀況
建議使用 chrome 開發,無論有沒有 SSL,chrome 的支援度都比其他瀏覽器完善,我們可以在 Application
> Service workers
查看有沒有正常運作
下載功能
由於在 SW 設定了所有 fetch
的請求都會儲存到緩存,所以只要把單行本的每一頁進行一次請求,便等於下載了,所以製作起來也比較簡單
這段可見在 SW 內把所有 /api/images
為首的請求都儲存到 comicImageCache
if (url.pathname.startsWith("/api/images")) {
comicImageCache.put(event.request, response.clone());
}
然後在客戶端請求所有頁面,然後儲存己下載的狀態到 localStorage
const vol = await comicService.getVol(comicId, volId);
try {
await Promise.all(
vol.pagePaths.map((path) => fetch(`/api/images?path=${path}`))
);
appStore.setVolDownloaded(comicId, volId);
} catch (error) {}
清除下載內容
我們可以使用 caches
API 從緩存裡找到 keys
和裡面的 url
的 ?path=
,然後從 path
可以判斷屬於哪本單行本,然後執行清除
const vol = await getVol(comicId, volId);
const comicImageCache = await caches.open("comic-images");
const keys = await comicImageCache.keys();
for (let key of keys) {
const cacheImagePath = new URL(key.url).searchParams.get("path");
if (cacheImagePath?.startsWith(vol.folder)) {
comicImageCache.delete(key);
}
}
appStore.setVolDownloaded(comicId, volId, false);
}
由於我們在客戶端用了
cache
API,所以在沒有 SW 的環境下大概會出現錯誤吧,如有需要再另行處理
為移動裝置作出的調整
基本的 viewport
設定
<meta name="viewport" content="width=device-width, initial-scale=1" />
slider 太貼近邊緣的話會觸發到上/下頁,所以特別把它縮短置中
<div class="w-2/3">
<input type="range" class="range range-xs" />
</div>
為 iOS 底部的 home bar 預留空間,另外避免把 slider 放到底部,因為也會觸發到切換 app 的功能
#bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
}
後記
印象中我只有在 2 個項目中使用過 PWA 吧,而其中一次是多年前的工作開發的一個後台網頁,加上這次的經驗都還只是略懂皮毛,但可能是工具比較完善的關係,感覺這次有變得比較容易一點開發了,尤其在更新緩存的方面… 這個 App 以後大概會繼續更新吧,我自己也是一個重度漫畫閱讀者,還有很多想要功能可以加入呢~